--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit 719e1634aafa9bcbeab236bcba0b25d6ef5bb7b6
Parents : bf5ad24
Author : Ivan <ivan@quad4.io>
Signature : Signature validation error
Date : 2026-04-22T20:29:26-05:00
feat(map): update map functionality with OpenFreeMap support and refactor tile caching logic
Changes
6 files changed, 357 insertions(+), 145 deletions(-)
Diff
diff --git a/meshchatx/src/frontend/components/map/MapPage.vue b/meshchatx/src/frontend/components/map/MapPage.vue
index 03aa54f8..7fd83454 100644
--- a/meshchatx/src/frontend/components/map/MapPage.vue
+++ b/meshchatx/src/frontend/components/map/MapPage.vue
@@ -377,9 +377,10 @@
>
<div class="h-px flex-1 bg-gray-100 dark:bg-zinc-800 ml-3"></div>
</div>
- <div class="grid grid-cols-4 gap-1">
+ <div class="grid grid-cols-5 gap-1">
<button
v-for="style in [
+ { id: 'openfreemap', label: 'OFM' },
{ id: 'osm', label: 'OSM' },
{ id: 'carto-dark', label: 'Dark' },
{ id: 'carto-voyager', label: 'Voy' },
@@ -388,6 +389,8 @@
:key="style.id"
class="py-1.5 text-[8px] font-bold uppercase rounded-md transition-all border leading-tight"
:class="
+ (style.id === 'openfreemap' &&
+ tileServerUrl.includes('tiles.openfreemap.org/styles/')) ||
(style.id === 'osm' && tileServerUrl.includes('openstreetmap.org')) ||
(style.id === 'carto-dark' &&
tileServerUrl.includes('basemaps.cartocdn.com/dark_all')) ||
@@ -877,11 +880,14 @@
<script>
import "ol/ol.css";
+import { apply as applyMapboxStyle } from "ol-mapbox-style";
import Map from "ol/Map";
import View from "ol/View";
+import LayerGroup from "ol/layer/Group";
import TileLayer from "ol/layer/Tile";
import VectorLayer from "ol/layer/Vector";
import XYZ from "ol/source/XYZ";
+import TileState from "ol/TileState";
import VectorSource from "ol/source/Vector";
import Feature from "ol/Feature";
import Point from "ol/geom/Point";
@@ -933,6 +939,9 @@ import MapExportProgressPanel from "./internal/MapExportProgressPanel.vue";
import MapNoMapWarning from "./internal/MapNoMapWarning.vue";
import MapLoadingOverlay from "./internal/MapLoadingOverlay.vue";
+const OPENFREEMAP_DEFAULT_STYLE = "https://tiles.openfreemap.org/styles/bright";
+const LEGACY_DEFAULT_OSM_RASTER = "https://tile.openstreetmap.org/{z}/{x}/{y}.png";
+
export default {
name: "MapPage",
components: {
@@ -981,7 +990,7 @@ export default {
cachingEnabled: true,
// tile server
- tileServerUrl: "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
+ tileServerUrl: OPENFREEMAP_DEFAULT_STYLE,
// search
searchQuery: "",
@@ -1136,7 +1145,7 @@ export default {
console.warn("Failed to load map state from cache", e);
}
- this.initMap();
+ await this.initMap();
if (this.telemetryList.length > 0) {
this.updateMarkers();
@@ -1251,6 +1260,14 @@ export default {
document.removeEventListener("click", this.handleClickOutside);
window.removeEventListener("resize", this.checkScreenSize);
WebSocketConnection.off("message", this.onWebsocketMessage);
+ if (this._pointerMoveRaf != null) {
+ cancelAnimationFrame(this._pointerMoveRaf);
+ this._pointerMoveRaf = null;
+ }
+ if (this.map) {
+ this.map.setTarget(null);
+ this.map = null;
+ }
},
methods: {
saveMapState() {
@@ -1360,17 +1377,7 @@ export default {
ToastUtils.error(this.$t("map.failed_save_storage"));
}
},
- initMap() {
- // Patch canvas getContext to address performance warning
- const originalGetContext = HTMLCanvasElement.prototype.getContext;
- HTMLCanvasElement.prototype.getContext = function (type, attributes) {
- if (type === "2d") {
- attributes = attributes || {};
- attributes.willReadFrequently = true;
- }
- return originalGetContext.call(this, type, attributes);
- };
-
+ async initMap() {
const defaultLat = parseFloat(this.config?.map_default_lat || 0);
const defaultLon = parseFloat(this.config?.map_default_lon || 0);
const defaultZoom = parseInt(this.config?.map_default_zoom || 2);
@@ -1382,13 +1389,11 @@ export default {
: fromLonLat([defaultLon, defaultLat]);
const startZoom = this.currentZoom !== 2 ? this.currentZoom : defaultZoom;
+ const baseLayer = await this.buildBaseMapLayer();
+ const mapPixelRatio = Math.min(typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1, 2);
this.map = new Map({
target: this.$refs.mapContainer,
- layers: [
- new TileLayer({
- source: this.getTileSource(),
- }),
- ],
+ layers: [baseLayer],
view: new View({
center: startCenter,
zoom: startZoom,
@@ -1397,6 +1402,8 @@ export default {
attribution: false,
rotate: false,
}),
+ pixelRatio: mapPixelRatio,
+ maxTilesLoading: 48,
});
// setup drawing layer
@@ -1648,6 +1655,7 @@ export default {
if (!url) return false;
const onlinePatterns = [
"openstreetmap.org",
+ "openfreemap.org",
"cartocdn.com",
"thunderforest.com",
"stamen.com",
@@ -1684,9 +1692,49 @@ export default {
return false;
}
},
+ isOpenFreeMapStyleUrl(url) {
+ return typeof url === "string" && url.includes("tiles.openfreemap.org/styles/");
+ },
+ async buildBaseMapLayer() {
+ const url = (this.tileServerUrl || OPENFREEMAP_DEFAULT_STYLE).trim();
+ if (!this.offlineEnabled && this.isOpenFreeMapStyleUrl(url)) {
+ const group = new LayerGroup();
+ await applyMapboxStyle(group, url);
+ return group;
+ }
+ return new TileLayer({
+ source: this.getTileSource(),
+ preload: 2,
+ transition: 0,
+ cacheSize: 896,
+ });
+ },
+ /**
+ * Decode tile blobs off the critical path where possible (createImageBitmap)
+ * and avoid object URLs when bitmap path succeeds.
+ */
+ async fastApplyBlobToTile(tile, blob) {
+ if (typeof createImageBitmap === "function") {
+ try {
+ const bitmap = await createImageBitmap(blob);
+ tile.setImage(bitmap);
+ return;
+ } catch {
+ /* fall through */
+ }
+ }
+ const el = tile.getImage();
+ if (el && "src" in el) {
+ const url = URL.createObjectURL(blob);
+ el.src = url;
+ setTimeout(() => URL.revokeObjectURL(url), 10000);
+ return;
+ }
+ tile.setState(TileState.ERROR);
+ },
getTileSource() {
const isOffline = this.offlineEnabled;
- const defaultTileUrl = "https://tile.openstreetmap.org/{z}/{x}/{y}.png";
+ const defaultTileUrl = OPENFREEMAP_DEFAULT_STYLE;
const customTileUrl = this.tileServerUrl || defaultTileUrl;
const isCustomLocal = this.isLocalUrl(customTileUrl);
const isDefaultOnline = this.isDefaultOnlineUrl(customTileUrl);
@@ -1699,7 +1747,7 @@ export default {
} else if (isCustomLocal) {
// It's a local/mesh URL, allow it
tileUrl = customTileUrl;
- } else if (customTileUrl !== defaultTileUrl) {
+ } else if (customTileUrl !== defaultTileUrl && customTileUrl !== LEGACY_DEFAULT_OSM_RASTER) {
// It's a custom URL that isn't a known online one,
// assume it might be a local mesh server with a domain.
tileUrl = customTileUrl;
@@ -1714,6 +1762,7 @@ export default {
const source = new XYZ({
url: tileUrl,
crossOrigin: "anonymous",
+ transition: 0,
});
// Track tile load errors to notify user if they appear to be offline
@@ -1742,18 +1791,15 @@ export default {
const response = await fetch(src);
if (!response.ok) {
if (response.status === 404) {
- tile.setState(3);
+ tile.setState(TileState.ERROR);
return;
}
throw new Error(`HTTP ${response.status}`);
}
const blob = await response.blob();
- const url = URL.createObjectURL(blob);
- tile.getImage().src = url;
- // Cleanup to prevent memory leaks
- setTimeout(() => URL.revokeObjectURL(url), 10000);
+ await this.fastApplyBlobToTile(tile, blob);
} catch {
- tile.setState(3);
+ tile.setState(TileState.ERROR);
}
});
} else {
@@ -1766,9 +1812,7 @@ export default {
try {
const cached = await TileCache.getTile(src);
if (cached) {
- const url = URL.createObjectURL(cached);
- tile.getImage().src = url;
- setTimeout(() => URL.revokeObjectURL(url), 10000);
+ await this.fastApplyBlobToTile(tile, cached);
return;
}
@@ -1777,12 +1821,11 @@ export default {
throw new Error(`HTTP ${response.status}`);
}
const blob = await response.blob();
- const url = URL.createObjectURL(blob);
- tile.getImage().src = url;
- setTimeout(() => URL.revokeObjectURL(url), 10000);
+ await this.fastApplyBlobToTile(tile, blob);
- // Background cache write to avoid blocking UI
- TileCache.setTile(src, blob).catch(() => {});
+ queueMicrotask(() => {
+ TileCache.setTile(src, blob).catch(() => {});
+ });
} catch {
originalTileLoadFunction(tile, src);
}
@@ -1799,14 +1842,14 @@ export default {
this.hasOfflineMap = true;
if (this.offlineEnabled) {
- this.updateMapSource();
+ await this.updateMapSource();
}
} else {
this.hasOfflineMap = false;
this.metadata = null;
if (this.offlineEnabled) {
this.offlineEnabled = false;
- this.updateMapSource();
+ await this.updateMapSource();
}
}
} catch {
@@ -1814,11 +1857,11 @@ export default {
this.metadata = null;
if (this.offlineEnabled) {
this.offlineEnabled = false;
- this.updateMapSource();
+ await this.updateMapSource();
}
}
},
- updateMapSource() {
+ async updateMapSource() {
if (!this.map) return;
const layers = this.map.getLayers();
@@ -1826,12 +1869,7 @@ export default {
// or just clear and re-add everything correctly
layers.clear();
- // 1. Tile layer
- layers.push(
- new TileLayer({
- source: this.getTileSource(),
- })
- );
+ layers.push(await this.buildBaseMapLayer());
// 2. Draw layer
if (this.drawLayer) {
@@ -1853,12 +1891,15 @@ export default {
this.showOfflineHint = false;
if (enabled) {
- const defaultTileUrl = "https://tile.openstreetmap.org/{z}/{x}/{y}.png";
+ const defaultTileUrl = OPENFREEMAP_DEFAULT_STYLE;
const defaultNominatimUrl = "https://nominatim.openstreetmap.org";
const isCustomTileLocal = this.isLocalUrl(this.tileServerUrl);
const isDefaultTileOnline = this.isDefaultOnlineUrl(this.tileServerUrl);
- const hasCustomTile = this.tileServerUrl && this.tileServerUrl !== defaultTileUrl;
+ const hasCustomTile =
+ this.tileServerUrl &&
+ this.tileServerUrl !== defaultTileUrl &&
+ this.tileServerUrl !== LEGACY_DEFAULT_OSM_RASTER;
const isCustomNominatimLocal = this.isLocalUrl(this.nominatimApiUrl);
const isDefaultNominatimOnline = this.isDefaultOnlineUrl(this.nominatimApiUrl);
@@ -1886,7 +1927,7 @@ export default {
this.isExportMode = false;
this.clearSearch();
}
- this.updateMapSource();
+ await this.updateMapSource();
await this.saveMapState();
// Persist setting
@@ -2006,7 +2047,7 @@ export default {
this.offlineEnabled = true;
await this.loadMBTilesList();
await this.checkOfflineMap();
- this.updateMapSource();
+ await this.updateMapSource();
ToastUtils.success(this.$t("map.upload_success"));
// If the map has bounds, we might want to fit to them
@@ -2055,7 +2096,7 @@ export default {
await window.api.patch("/api/v1/config", {
map_tile_server_url: this.tileServerUrl,
});
- this.updateMapSource();
+ await this.updateMapSource();
ToastUtils.success(this.$t("map.tile_server_saved"));
await this.saveMapState();
} catch {
@@ -2065,8 +2106,10 @@ export default {
setTileServer(type) {
this.tileErrorCount = 0;
this.showOfflineHint = false;
- if (type === "osm") {
- this.tileServerUrl = "https://tile.openstreetmap.org/{z}/{x}/{y}.png";
+ if (type === "openfreemap") {
+ this.tileServerUrl = OPENFREEMAP_DEFAULT_STYLE;
+ } else if (type === "osm") {
+ this.tileServerUrl = LEGACY_DEFAULT_OSM_RASTER;
} else if (type === "carto-dark") {
this.tileServerUrl = "https://basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png";
} else if (type === "carto-voyager") {
@@ -2667,36 +2710,46 @@ export default {
if (evt.dragging || this.isDrawing || this.isMeasuring) return;
const pixel = this.map.getEventPixel(evt.originalEvent);
- const feature = this.map.forEachFeatureAtPixel(pixel, (f) => f);
+ if (this._pointerMoveRaf != null) {
+ this._pendingPointerPixel = pixel;
+ return;
+ }
+ this._pendingPointerPixel = pixel;
+ this._pointerMoveRaf = requestAnimationFrame(() => {
+ this._pointerMoveRaf = null;
+ const p = this._pendingPointerPixel;
+ this._pendingPointerPixel = null;
+ if (!this.map || !p) return;
+
+ const feature = this.map.forEachFeatureAtPixel(p, (f) => f);
+
+ if (feature) {
+ const hasNote = feature.get("note") || (feature.get("telemetry") && feature.get("telemetry").note);
+ if (hasNote) {
+ this.hoveredFeature = feature;
+ } else {
+ this.hoveredFeature = null;
+ }
- if (feature) {
- const hasNote = feature.get("note") || (feature.get("telemetry") && feature.get("telemetry").note);
- if (hasNote) {
- this.hoveredFeature = feature;
+ const isMarker = feature.get("telemetry") || feature.get("discovered") || feature.get("cluster");
+ if (isMarker && this.hoveredMarker !== feature) {
+ const oldHovered = this.hoveredMarker;
+ this.hoveredMarker = feature;
+ feature.changed();
+ if (oldHovered) oldHovered.changed();
+ }
+
+ this.map.getTargetElement().style.cursor = "pointer";
} else {
this.hoveredFeature = null;
+ if (this.hoveredMarker) {
+ const oldHovered = this.hoveredMarker;
+ this.hoveredMarker = null;
+ oldHovered.changed();
+ }
+ this.map.getTargetElement().style.cursor = "";
}
-
- // Handle marker hover effects
- const isMarker = feature.get("telemetry") || feature.get("discovered") || feature.get("cluster");
- if (isMarker && this.hoveredMarker !== feature) {
- const oldHovered = this.hoveredMarker;
- this.hoveredMarker = feature;
- // Trigger style refresh
- feature.changed();
- if (oldHovered) oldHovered.changed();
- }
-
- this.map.getTargetElement().style.cursor = "pointer";
- } else {
- this.hoveredFeature = null;
- if (this.hoveredMarker) {
- const oldHovered = this.hoveredMarker;
- this.hoveredMarker = null;
- oldHovered.changed();
- }
- this.map.getTargetElement().style.cursor = "";
- }
+ });
},
handleMapClick(evt) {
diff --git a/meshchatx/src/frontend/js/TileCache.js b/meshchatx/src/frontend/js/TileCache.js
index 5e19c724..ff39a5ff 100644
--- a/meshchatx/src/frontend/js/TileCache.js
+++ b/meshchatx/src/frontend/js/TileCache.js
@@ -62,10 +62,11 @@ class TileCache {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([STORE_NAME], "readwrite");
const store = transaction.objectStore(STORE_NAME);
- const request = store.put(data, key);
+ store.put(data, key);
- request.onsuccess = () => resolve();
- request.onerror = () => reject(request.error);
+ transaction.oncomplete = () => resolve();
+ transaction.onerror = () => reject(transaction.error);
+ transaction.onabort = () => reject(transaction.error || new Error("IndexedDB transaction aborted"));
});
}
@@ -86,10 +87,11 @@ class TileCache {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([STATE_STORE], "readwrite");
const store = transaction.objectStore(STATE_STORE);
- const request = store.put(data, key);
+ store.put(data, key);
- request.onsuccess = () => resolve();
- request.onerror = () => reject(request.error);
+ transaction.oncomplete = () => resolve();
+ transaction.onerror = () => reject(transaction.error);
+ transaction.onabort = () => reject(transaction.error || new Error("IndexedDB transaction aborted"));
});
}
diff --git a/tests/frontend/MapDrawing.test.js b/tests/frontend/MapDrawing.test.js
index 29fea77e..eecf10b7 100644
--- a/tests/frontend/MapDrawing.test.js
+++ b/tests/frontend/MapDrawing.test.js
@@ -14,6 +14,13 @@ vi.mock("@/js/TileCache", () => ({
},
}));
+vi.mock("ol-mapbox-style", () => ({
+ apply: vi.fn().mockResolvedValue(undefined),
+}));
+vi.mock("ol/layer/Group", () => ({
+ default: vi.fn(function () {}),
+}));
+
// Mock OpenLayers (Vitest 4: constructor mocks must use function/class, not arrow fns)
vi.mock("ol/Map", () => ({
default: vi.fn().mockImplementation(function () {
@@ -313,6 +320,7 @@ describe("MapPage.vue - Drawing and Measurement Tools", () => {
it("saves a drawing layer", async () => {
const wrapper = mountMapPage();
await wrapper.vm.$nextTick();
+ await new Promise((resolve) => setTimeout(resolve, 50));
wrapper.vm.showSaveDrawingModal = true;
wrapper.vm.newDrawingName = "Test Layer";
diff --git a/tests/frontend/MapPage.test.js b/tests/frontend/MapPage.test.js
index 1dbe8001..5d357e5b 100644
--- a/tests/frontend/MapPage.test.js
+++ b/tests/frontend/MapPage.test.js
@@ -13,6 +13,13 @@ vi.mock("@/js/TileCache", () => ({
},
}));
+vi.mock("ol-mapbox-style", () => ({
+ apply: vi.fn().mockResolvedValue(undefined),
+}));
+vi.mock("ol/layer/Group", () => ({
+ default: vi.fn(function () {}),
+}));
+
// Mock OpenLayers (Vitest 4: constructor mocks must use function/class, not arrow fns)
vi.mock("ol/Map", () => ({
default: vi.fn().mockImplementation(function () {
diff --git a/tests/frontend/MapPageClustering.test.js b/tests/frontend/MapPageClustering.test.js
index 11ba280b..65ae9196 100644
--- a/tests/frontend/MapPageClustering.test.js
+++ b/tests/frontend/MapPageClustering.test.js
@@ -12,6 +12,13 @@ vi.mock("@/js/TileCache", () => ({
},
}));
+vi.mock("ol-mapbox-style", () => ({
+ apply: vi.fn().mockResolvedValue(undefined),
+}));
+vi.mock("ol/layer/Group", () => ({
+ default: vi.fn(function () {}),
+}));
+
const viewMock = {
on: vi.fn(),
setCenter: vi.fn(),
diff --git a/tests/frontend/TileCache.test.js b/tests/frontend/TileCache.test.js
index 7128a803..10b3972c 100644
--- a/tests/frontend/TileCache.test.js
+++ b/tests/frontend/TileCache.test.js
@@ -1,82 +1,217 @@
-import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { describe, it, expect, vi, beforeEach, afterEach, beforeAll } from "vitest";
+
+const DB_NAME = "meshchat_map_cache";
+
+function tag(o) {
+ return Object.prototype.toString.call(o);
+}
+
+function tilePayloadToUint8(got) {
+ if (got == null) {
+ return null;
+ }
+ const t = tag(got);
+ if (t === "[object Uint8Array]") {
+ return got;
+ }
+ if (t === "[object ArrayBuffer]") {
+ return new Uint8Array(got);
+ }
+ if (Array.isArray(got)) {
+ return Uint8Array.from(got);
+ }
+ return null;
+}
+
+async function tilePayloadToUint8Async(got) {
+ if (got == null) {
+ return null;
+ }
+ if (tag(got) === "[object Blob]") {
+ return new Uint8Array(await got.arrayBuffer());
+ }
+ return tilePayloadToUint8(got);
+}
+
+function deleteMapCacheDb() {
+ const idb = globalThis.indexedDB;
+ if (!idb) {
+ return Promise.resolve();
+ }
+ return new Promise((resolve, reject) => {
+ const req = idb.deleteDatabase(DB_NAME);
+ req.onsuccess = () => resolve();
+ req.onblocked = () => resolve();
+ req.onerror = () => reject(req.error);
+ });
+}
describe("TileCache.js", () => {
- let TileCache;
- const DB_NAME = "meshchat_map_cache";
- const DB_VERSION = 2;
-
- beforeEach(async () => {
- vi.resetModules();
- vi.clearAllMocks();
-
- // Clear all possible indexedDB properties
- delete window.indexedDB;
- delete window.mozIndexedDB;
- delete window.webkitIndexedDB;
- delete window.msIndexedDB;
- delete globalThis.indexedDB;
- });
+ describe("indexedDB detection (mocked)", () => {
+ const DB_VERSION = 2;
+ let savedIndexedDb;
+ let savedGlobalIndexedDb;
- it("should support window.indexedDB", async () => {
- const mockRequest = { onsuccess: null, onerror: null };
- const mockOpen = vi.fn().mockReturnValue(mockRequest);
- window.indexedDB = { open: mockOpen };
+ beforeEach(async () => {
+ savedIndexedDb = window.indexedDB;
+ savedGlobalIndexedDb = globalThis.indexedDB;
+ vi.resetModules();
+ vi.clearAllMocks();
- // Re-import to trigger constructor and init
- const module = await import("@/js/TileCache");
- const cache = module.default;
+ delete window.indexedDB;
+ delete window.mozIndexedDB;
+ delete window.webkitIndexedDB;
+ delete window.msIndexedDB;
+ delete globalThis.indexedDB;
+ });
- expect(mockOpen).toHaveBeenCalledWith(DB_NAME, DB_VERSION);
- });
+ afterEach(() => {
+ if (savedIndexedDb !== undefined) {
+ window.indexedDB = savedIndexedDb;
+ }
+ if (savedGlobalIndexedDb !== undefined) {
+ globalThis.indexedDB = savedGlobalIndexedDb;
+ }
+ });
- it("should support vendor prefixes (mozIndexedDB)", async () => {
- const mockRequest = { onsuccess: null, onerror: null };
- const mockOpen = vi.fn().mockReturnValue(mockRequest);
- window.mozIndexedDB = { open: mockOpen };
+ it("should support window.indexedDB", async () => {
+ const mockRequest = { onsuccess: null, onerror: null };
+ const mockOpen = vi.fn().mockReturnValue(mockRequest);
+ window.indexedDB = { open: mockOpen };
- const module = await import("@/js/TileCache");
- const cache = module.default;
+ await import("@/js/TileCache");
- expect(mockOpen).toHaveBeenCalledWith(DB_NAME, DB_VERSION);
- });
+ expect(mockOpen).toHaveBeenCalledWith(DB_NAME, DB_VERSION);
+ });
- it("should support vendor prefixes (webkitIndexedDB)", async () => {
- const mockRequest = { onsuccess: null, onerror: null };
- const mockOpen = vi.fn().mockReturnValue(mockRequest);
- window.webkitIndexedDB = { open: mockOpen };
+ it("should support vendor prefixes (mozIndexedDB)", async () => {
+ const mockRequest = { onsuccess: null, onerror: null };
+ const mockOpen = vi.fn().mockReturnValue(mockRequest);
+ window.mozIndexedDB = { open: mockOpen };
- const module = await import("@/js/TileCache");
- const cache = module.default;
+ await import("@/js/TileCache");
- expect(mockOpen).toHaveBeenCalledWith(DB_NAME, DB_VERSION);
- });
+ expect(mockOpen).toHaveBeenCalledWith(DB_NAME, DB_VERSION);
+ });
- it("should support vendor prefixes (msIndexedDB)", async () => {
- const mockRequest = { onsuccess: null, onerror: null };
- const mockOpen = vi.fn().mockReturnValue(mockRequest);
- window.msIndexedDB = { open: mockOpen };
+ it("should support vendor prefixes (webkitIndexedDB)", async () => {
+ const mockRequest = { onsuccess: null, onerror: null };
+ const mockOpen = vi.fn().mockReturnValue(mockRequest);
+ window.webkitIndexedDB = { open: mockOpen };
- const module = await import("@/js/TileCache");
- const cache = module.default;
+ await import("@/js/TileCache");
- expect(mockOpen).toHaveBeenCalledWith(DB_NAME, DB_VERSION);
- });
+ expect(mockOpen).toHaveBeenCalledWith(DB_NAME, DB_VERSION);
+ });
- it("should support globalThis.indexedDB", async () => {
- const mockRequest = { onsuccess: null, onerror: null };
- const mockOpen = vi.fn().mockReturnValue(mockRequest);
- globalThis.indexedDB = { open: mockOpen };
+ it("should support vendor prefixes (msIndexedDB)", async () => {
+ const mockRequest = { onsuccess: null, onerror: null };
+ const mockOpen = vi.fn().mockReturnValue(mockRequest);
+ window.msIndexedDB = { open: mockOpen };
- const module = await import("@/js/TileCache");
- const cache = module.default;
+ await import("@/js/TileCache");
- expect(mockOpen).toHaveBeenCalledWith(DB_NAME, DB_VERSION);
- });
+ expect(mockOpen).toHaveBeenCalledWith(DB_NAME, DB_VERSION);
+ });
+
+ it("should support globalThis.indexedDB", async () => {
+ const mockRequest = { onsuccess: null, onerror: null };
+ const mockOpen = vi.fn().mockReturnValue(mockRequest);
+ globalThis.indexedDB = { open: mockOpen };
+
+ await import("@/js/TileCache");
- it("should reject if IndexedDB is not supported", async () => {
- const module = await import("@/js/TileCache");
- const cache = module.default;
+ expect(mockOpen).toHaveBeenCalledWith(DB_NAME, DB_VERSION);
+ });
+
+ it("should reject if IndexedDB is not supported", async () => {
+ const module = await import("@/js/TileCache");
+ const cache = module.default;
+
+ await expect(cache.initPromise).rejects.toBe("IndexedDB not supported");
+ });
+ });
- await expect(cache.initPromise).rejects.toBe("IndexedDB not supported");
+ describe("tile and map_state storage (fake-indexeddb)", () => {
+ let TileCache;
+
+ beforeAll(async () => {
+ vi.resetModules();
+ await deleteMapCacheDb();
+ const mod = await import("@/js/TileCache");
+ TileCache = mod.default;
+ await TileCache.initPromise;
+ });
+
+ beforeEach(async () => {
+ await TileCache.clear();
+ });
+
+ it("stores and retrieves binary tile data under the URL key", async () => {
+ const key = "https://tiles.example/z/x/y.png";
+ const bytes = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
+
+ // IndexedDB structured clone stores ArrayBuffer reliably in jsdom + fake-indexeddb.
+ // MapPage passes Blob from fetch(); both Blob and ArrayBuffer are valid values.
+ await TileCache.setTile(key, bytes.buffer);
+ const got = await TileCache.getTile(key);
+
+ const out = await tilePayloadToUint8Async(got);
+ expect(out).not.toBeNull();
+ expect(Array.from(out)).toEqual(Array.from(bytes));
+ });
+
+ it("stores and retrieves a tile Blob under the URL key when the runtime preserves Blob", async () => {
+ const key = "https://tiles.example/blob.png";
+ const bytes = Uint8Array.from([0x89, 0x50, 0x4e, 0x47]);
+ const blob = new Blob([bytes], { type: "image/png" });
+
+ await TileCache.setTile(key, blob);
+ const got = await TileCache.getTile(key);
+
+ if (!(got instanceof Blob)) {
+ return;
+ }
+
+ expect(got.type).toBe("image/png");
+ expect(got.size).toBe(bytes.length);
+ const out = new Uint8Array(await got.arrayBuffer());
+ expect(Array.from(out)).toEqual(Array.from(bytes));
+ });
+
+ it("returns undefined for a missing tile key", async () => {
+ const got = await TileCache.getTile("https://missing.example/0/0/0.png");
+ expect(got).toBeUndefined();
+ });
+
+ it("overwrites an existing tile key", async () => {
+ const key = "https://tiles.example/same.png";
+ await TileCache.setTile(key, Uint8Array.from([1, 2, 3]).buffer);
+ await TileCache.setTile(key, Uint8Array.from([9, 9]).buffer);
+
+ const got = await TileCache.getTile(key);
+ const out = await tilePayloadToUint8Async(got);
+ expect(out).not.toBeNull();
+ expect(out.length).toBe(2);
+ expect(out[0]).toBe(9);
+ });
+
+ it("stores and retrieves map state independently of tiles", async () => {
+ const state = { center: [12.5, 55.1], zoom: 7, offlineEnabled: false };
+ await TileCache.setMapState("last_view", state);
+ const got = await TileCache.getMapState("last_view");
+
+ expect(got).toEqual(state);
+ });
+
+ it("clear removes tiles and map state", async () => {
+ await TileCache.setTile("https://x/t.png", Uint8Array.from([97]).buffer);
+ await TileCache.setMapState("last_view", { zoom: 1 });
+
+ await TileCache.clear();
+
+ expect(await TileCache.getTile("https://x/t.png")).toBeUndefined();
+ expect(await TileCache.getMapState("last_view")).toBeUndefined();
+ });
});
});
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────